Skip to content

[PAR-848] Configurable Express Button Styles - #69

Merged
mescalantea merged 10 commits into
masterfrom
feature/PAR-848-Configurable-Express-Button-Styles
Aug 26, 2026
Merged

[PAR-848] Configurable Express Button Styles#69
mescalantea merged 10 commits into
masterfrom
feature/PAR-848-Configurable-Express-Button-Styles

Conversation

@mescalantea

@mescalantea mescalantea commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What is the goal?

Merchants can now style the express checkout button from the Merchant Portal — colours, corner radius, font size and uppercase text. This package carries that configuration from the portal out to the storefront.

References

How is it being implemented?

  • A buttonStyle string is threaded through the settings model, the stored entity, the configuration webhook and both availability responses.
  • This package never looks inside it. It checks the string parses as JSON and nothing else, so adding a style property later touches only the portal and the CDN asset — not this package, not any deployed plugin. The rendered button holds the allow-list and does all sanitising.
  • No migration: the entity already stores a serialised blob, and settings written before this change load as unset without throwing.
  • The value rides the availability response rather than the integration interface, which would fatal every deployed plugin if extended.

Caveats

Nothing bounds the blob's length here. The CDN library caps what it forwards, so an oversized value degrades rather than breaking.

Does it affect (changes or update) any sensitive data?

No. Presentation values a merchant picks for their own storefront button.

How is it tested?

Automated tests, including that an attribute this release does not know survives the round trip untouched, and that one store's style never reaches another.

How is it going to be deployed?

Standard deployment.

mescalantea and others added 7 commits August 18, 2026 16:21
Only the Merchant Portal and the rendered button know the attribute
names, so the model never inspects the string and the parameter is
defaulted, leaving existing construction sites untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… entity

A non-string or absent persisted value degrades to null rather than
throwing, so one bad row cannot brick the whole settings load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the webhook

Only JSON well-formedness is checked. Validating attribute names or
values here would put this package and every deployed plugin back in the
release path for each new style property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carried on the availability responses rather than added to the
integration interface, which would fatal every deployed plugin. Resolved
only when a button actually renders: the service already loads the same
settings to check the page toggle, so an unconditional read would double
the storage hits on every product and cart page view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty string is not well-formed JSON, so clearing the field returned
400 and left the previous style live with no way to revert. It now means
the same as an absent value.

The new response parameters are defaulted so a plugin constructing them
directly does not fatal on upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y shape

The persisted array was rebuilt by hand although the domain model
already produces it, so adding one field meant editing two files and
the copies could drift apart unnoticed. The sibling banner settings
entity already delegates the same way.

An empty style now means unset in the model rather than in one of its
callers, so every writer gets the rule instead of just the webhook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scoping held, but nothing asserted it, and a configuration leaking
between merchant stores is the kind of defect worth a test rather than a
proof. Removing the store filter from the repository makes this fail
with one store reading another's style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@m1k3lm m1k3lm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review of the Express Checkout button-style changes — 14 findings, ordered most severe first in the threads below.

Worth blocking on:

  • A partial webhook payload silently wipes a merchant's saved buttonStyle.
  • The merchant-controlled style blob reaches the storefront unescaped, unvalidated and unbounded.
  • The well-formed-JSON invariant is enforced at exactly one edge; the domain model and entity accept anything.

The rest: a doubled settings query on the storefront hot path, two tests that don't prove what they claim, and a cluster of DRY/altitude cleanups.

Generated with Claude Code

}

return new self($configs);
$buttonStyle = $payload['buttonStyle'] ?? null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 correctness

A save-express-checkout-settings webhook payload that omits buttonStyle silently wipes the merchant's stored style instead of leaving it untouched.

$payload['buttonStyle'] ?? null cannot distinguish "key absent" from "explicitly cleared", so transformToDomainModel() builds ExpressCheckoutSettings($configs, null) and setExpressCheckoutSettings() upserts it.

A merchant configures a style, then any client that posts only {topic, expressCheckoutConfigs} — an older portal build, a targeted page-enable/disable call, a retried partial payload — resets buttonStyle to null and the storefront button reverts to defaults with no error.

The PR's own test at ConfigurationWebhookAPITest.php:2303 (assertNull($persisted->getButtonStyle()) after a payload with no buttonStyle) locks this behaviour in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this, but flagging it as a product decision rather than a bug.

save-express-checkout-settings is a full-replace save: expressCheckoutConfigs behaves exactly the same way, so a payload that omits it clears the configs too. Under those semantics buttonStyle going back to null when it is absent is correct, and the test at ConfigurationWebhookAPITest.php:2303 documents that intent rather than locking a defect.

If the topic is meant to be a partial patch instead, then this is a real bug and the fix is distinguishing "key absent" from "explicitly cleared" — but that is a contract question for the portal (sequra/merchant-portal-frontend#1731), not something to guess at here. Leaving the thread open for that.

return ['available' => $this->available];
return [
'available' => $this->available,
'buttonStyle' => $this->buttonStyle,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 security

A merchant-controlled string is echoed verbatim into the public storefront availability response with no escaping, no content validation and no length bound, making every deployed plugin solely responsible for preventing stored XSS.

ConfigurationWebhookAPITest.php:2348 explicitly asserts that '{"attributeThisReleaseNeverHeardOf":"<script>x</script>", ...}' survives the round trip untouched. That string is persisted and later returned by isAvailable()/isAvailableForGuest().

Any integration that interpolates buttonStyle into an inline <style>/<script>/attribute rather than passing it through JSON.parse + the CDN allow-list gets stored XSS on every product and cart page.

The core has the choke point here (one toArray()) and does nothing with it. At minimum re-encode via json_encode(json_decode(...)) to strip anything that isn't valid JSON structure, and cap the length.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly taken, partly declined — leaving open for your call.

Taken: the length bound is in (62d572a), and the style must now decode to a JSON object, so the blob is no longer unbounded or structurally arbitrary.

Declined: the json_encode(json_decode(...)) re-encode. It does not sanitise values — PHP does not escape < by default, and with JSON_HEX_TAG it becomes \u003C, which JSON.parse turns straight back into <. So it only helps a plugin that interpolates the blob into an inline <script>, and that plugin has larger problems than this field.

On the escaping premise: the validation exists, in integration-assets#669, and it is a whitelist at the point of use — five known keys, a hex regex for colours, a token map for the radius, a clamped font size, all applied through style.setProperty and never through the markup template. The attributeThisReleaseNeverHeardOf value in that test is not one of the five keys, so it is dropped before its value is ever read; put <script> in backgroundColor and it fails the regex. The blob reaching the plugin as a data- attribute still needs ordinary attribute escaping, which esc_attr / escapeHtmlAttr already do for every attribute.

The core deliberately does not know the key names: they are owned by the portal and the button page so that adding one does not need a release of this library and an update of every installed plugin. Checking the shape (object, bounded) is schema-agnostic and compatible with that; checking the contents would not be.

return $config->toArray();
}, $this->expressCheckoutSettings->getExpressCheckoutConfigs()),
];
$data['expressCheckoutSettings'] = $this->expressCheckoutSettings->toArray();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 correctness

Replacing the entity's explicit persistence mapping with $this->expressCheckoutSettings->toArray() couples the on-disk storage format to the domain model's serialization, so any future field added to the domain silently changes what is written to storage.

The deleted code spelled out exactly which keys are persisted.

Now, if someone adds a derived / presentation field to ExpressCheckoutSettings::toArray() (a resolved default style, a computed enabledPages list), it is written into every stored row without anyone touching the DataAccess layer — and inflate(), which reads only expressCheckoutConfigs and buttonStyle, silently drops it on the next load, producing an asymmetric round trip.

Keep the entity's mapping explicit, or add a matching ExpressCheckoutSettings::fromArray() so the two halves stay symmetric by construction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined. The asymmetry is real, but there is no defect today, and the explicit mapping was removed on purpose in 2fbc9c1 to stop the same field being spelled out in three places. Reinstating it, or adding a fromArray() to pair with it, is more code for a hypothetical field. Noted as a footgun rather than fixed.

/**
* @var string|null
*/
protected $buttonStyle;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ simplification

GuestExpressCheckoutAvailabilityResponse now duplicates a third field ($buttonStyle) already declared identically in ExpressCheckoutAvailabilityResponse, along with its docblock, constructor assignment and toArray() entry.

The two classes now hold identical protected $available + protected $buttonStyle declarations, identical @var string|null docblocks, identical assignments and identical 'buttonStyle' => $this->buttonStyle lines — the guest response is ExpressCheckoutAvailabilityResponse plus one array field.

A fourth style-related field means editing both files again, and a fix applied to only one (e.g. re-encoding the blob, per the escaping finding) silently leaves the guest endpoint unprotected.

Have the guest response extend ExpressCheckoutAvailabilityResponse and add only availableCountries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined. The argument that carried this one was that a fix applied to one response would leave the other unprotected — and that dissolves now that the validation lives in the domain model rather than in either DTO. What is left is cosmetic duplication of two fields.

$configs = $this->expressCheckoutSettings
? $this->expressCheckoutSettings->getExpressCheckoutConfigs()
: [];
$buttonStyle = $this->expressCheckoutSettings

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ simplification

GetExpressCheckoutSettingsResponse::toArray() hand-rebuilds the settings shape field by field even though the same PR just refactored the ORM entity to delegate to ExpressCheckoutSettings::toArray().

Adding buttonStyle required editing this file (two new lines plus a second null-guard ternary) and the domain toArray() and the entity — three places for one field.

This could be $this->expressCheckoutSettings ? $this->expressCheckoutSettings->toArray() : ['expressCheckoutConfigs' => [], 'buttonStyle' => null] merged with availablePages.

As written, the persisted shape and the webhook GET shape are two independent definitions kept in sync by hand; the next field will drift between them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, same reasoning as the guest-response thread: cosmetic duplication with no correctness consequence now that the shape is validated once in the domain. Worth doing the next time this response changes for another reason.

Comment thread src/BusinessLogic/Domain/ExpressCheckout/Models/ExpressCheckoutSettings.php Outdated
mescalantea and others added 3 commits August 25, 2026 12:17
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mescalantea
mescalantea merged commit 203f670 into master Aug 26, 2026
5 checks passed
@mescalantea
mescalantea deleted the feature/PAR-848-Configurable-Express-Button-Styles branch August 26, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants